const {
  ui: {
    form: {
      FocusElement,
      ScrollBar
    }
  },
  util: {
    ansi,
    telchars: telc
  }
} = require('tui-lib')

class TuiTextEditor extends FocusElement {
  constructor() {
    super()

    this.cursorSourceLine = 0
    this.cursorSourceCol = 0
    this.cursorVisible = true
    this.idealCursorSourceCol = 0

    this.scrollLine = 0

    this.sourceLines = []
    this.uiLines = []

    this.eraseWordBoundary = ' .*!@#$%^&*()-=+[]{}\\|;:,.<>/?`~'
    this.movementWordBoundary = ' '

    this.tabString = '    '

    this.statusMessageScreenTime = 3000
    this.statusMessages = []

    this.scrollBar = new ScrollBar({
      getLayoutType: () => 'vertical',
      getCurrentScroll: () => this.scrollLine,
      getMaximumScroll: () => this.uiLines.length - this.contentH,
      getTotalItems: () => this.uiLines.length
    })
    this.addChild(this.scrollBar)

    this.statusLineVisible = true

    this.hasBeenEdited = false

    this.rebuildUiLines()
  }

  fixLayout() {
    this.scrollBar.fixLayout()
    this.rebuildUiLines()
    this.scrollCursorIntoView()
  }

  rebuildUiLines() {
    this.uiLines = []

    const { sourceLines } = this
    if (sourceLines.length === 0) {
      sourceLines.push({text: ''})
    }

    for (const line of this.displayedSourceLines) {
      this.uiLines.push(...this.wrapLine(line))
    }

    this.scheduleDrawWithoutPropertyChange()
  }

  keyPressed(keyBuf) {
    let clearEscape = true
    let bubble = false
    if (telc.isDown(keyBuf)) {
      if (this.cursorSourceLine < this.displayedSourceLines.length - 1) {
        this.cursorSourceLine++
        this.restoreIdealCursorSourceCol()
        this.scrollCursorIntoView()
      }
    } else if (telc.isUp(keyBuf)) {
      if (this.cursorSourceLine > 0) {
        this.cursorSourceLine--
        this.restoreIdealCursorSourceCol()
        this.scrollCursorIntoView()
      }
    } else if (telc.isLeft(keyBuf)) {
      this.cursorSourceCol--
      if (this.cursorSourceCol < 0) {
        if (this.cursorSourceLine > 0) {
          this.cursorSourceLine--
          this.cursorSourceCol = this.sourceLines[this.cursorSourceLine].text.length
        } else {
          this.cursorSourceCol++
        }
      }
      this.idealCursorSourceCol = this.cursorSourceCol
      this.scrollCursorIntoView()
    } else if (telc.isRight(keyBuf)) {
      this.cursorSourceCol++
      if (this.cursorSourceCol > this.sourceLines[this.cursorSourceLine].text.length) {
        if (this.cursorSourceLine < this.displayedSourceLines.length - 1) {
          this.cursorSourceLine++
          this.cursorSourceCol = 0
        } else {
          this.cursorSourceCol--
        }
      }
      this.idealCursorSourceCol = this.cursorSourceCol
      this.scrollCursorIntoView()
    } else if (telc.isControlLeft(keyBuf)) {
      this.moveCursorTo(this.getStartOfWord(this.movementWordBoundary, this.cursorPosition))
    } else if (telc.isControlRight(keyBuf)) {
      this.moveCursorTo(this.getEndOfWord(this.movementWordBoundary, this.cursorPosition))
    } else if (keyBuf[0] === 12) { // ^L
      // this.rebuildUiLines()
      this.scrollCursorIntoView()
    } else if (keyBuf[0] === 1) { // ^A
      this.moveCursorTo(this.getStartOfLine(this.cursorPosition))
    } else if (keyBuf[0] === 5) { // ^E
      this.moveCursorTo(this.getEndOfLine(this.cursorPosition))
      this.idealCursorSourceCol = 'end'
    } else if (keyBuf[0] === 11) { // ^K
      if (this.cursorPosition[1] === this.getEndOfLine(this.cursorPosition)[1]) {
        if (this.cursorPosition[0] < this.displayedSourceLines.length - 1) {
          this.eraseRange(this.cursorPosition, [this.cursorPosition[0] + 1, 0])
        }
      } else {
        this.eraseRange(this.cursorPosition, this.getEndOfLine(this.cursorPosition))
      }
    } else if (telc.isBackspace(keyBuf)) {
      if (this.escapeJustPressed) {
        this.eraseWordAtCursor()
      } else {
        this.eraseCharacterAtCursor()
      }
    } else if (keyBuf[0] === 0x1b && keyBuf[1] === 0x7f) {
      this.eraseWordAtCursor()
    } else if (keyBuf[0] === 0x17) {
      this.eraseWordAtCursor()
    } else if (telc.isDelete(keyBuf)) {
      if (this.escapeJustPressed) {
        this.eraseWordAtCursorForward()
      } else {
        this.eraseCharacterAtCursorForward()
      }
    } else if (keyBuf[0] === 0x1b) {
      if (keyBuf.length === 1) { // Esc
        this.escapeJustPressed = true
        clearEscape = false
      } else {
        // Some escape code - do nothing.
        bubble = true
      }
    } else if (keyBuf[0] === 0x0d) { // \r
      this.insertAtCursor(keyBuf.toString())
    } else if (keyBuf[0] === 0x09) { // \t
      this.insertAtCursor(this.tabString)
    } else if (keyBuf[0] < 0x20) {
      // Some other non-printable character - do nothing.
      bubble = true
    } else {
      this.insertAtCursor(keyBuf.toString())
    }

    if (clearEscape) {
      this.escapeJustPressed = false
    }

    return bubble
  }

  restoreIdealCursorSourceCol() {
    const end = this.sourceLines[this.cursorSourceLine].text.length

    if (this.idealCursorSourceCol === 'end') {
      this.cursorSourceCol = end
    } else {
      this.cursorSourceCol = Math.min(this.idealCursorSourceCol, end)
    }
  }

  clearSourceAndLoadText(text) {
    this.sourceLines = text.split('\n').map(text => ({text}))
    this.rebuildUiLines()
    this.moveCursorTo([0, 0])
  }

  getSourceText() {
    return this.sourceLines.map(({text}) => text).join('\n')
  }

  insertAtCursor(text) {
    while (this.sourceLines.length <= this.cursorSourceLine) {
      const sourceLine = {text: ''}
      this.sourceLines.push(sourceLine)
      this.uiLines.push({sourceLine, startI: 0, endI: 0})
    }

    this.markEditStatus()
    this.moveCursorTo(this.insert(text, this.cursorPosition))
  }

  eraseCharacterAtCursor() {
    this.moveCursorTo(this.eraseCharacter(this.cursorPosition))
  }

  eraseCharacterAtCursorForward() {
    this.moveCursorTo(this.eraseCharacterForward(this.cursorPosition))
  }

  eraseWordAtCursor() {
    this.moveCursorTo(this.eraseWord(this.cursorPosition))
  }

  eraseWordAtCursorForward() {
    this.moveCursorTo(this.eraseWordForward(this.cursorPosition))
  }

  moveCursorTo([sourceLine, sourceCol]) {
    this.cursorSourceLine = sourceLine
    this.cursorSourceCol = sourceCol
    this.idealCursorSourceCol = sourceCol
    this.scrollCursorIntoView()
  }

  scrollCursorIntoView() {
    this.scrollIntoView([this.cursorUiLine, this.cursorUiCol])
  }

  scrollIntoView([uiLine,]) {
    const bottomEdge = this.scrollLine + this.contentH - 1
    const delta = uiLine - bottomEdge
    if (delta > 0) {
      this.scrollLine += delta
    } else if (uiLine < this.scrollLine) {
      this.scrollLine = uiLine
    }
  }

  insert(newText, [sourceLine, sourceCol]) {
    const line = this.sourceLines[sourceLine]

    const newTexts = newText.split(/\r?\n|\r/)

    let resultSourceLine, resultSourceCol

    let newSourceLines = []

    if (newTexts.length === 1) {
      line.text = line.text.slice(0, sourceCol) + newText + line.text.slice(sourceCol)
      resultSourceLine = sourceLine
      resultSourceCol = sourceCol + newText.length
    } else {
      const prefix = line.text.slice(0, line.text.length - line.text.trimLeft().length)
      const afterText = line.text.slice(sourceCol)
      line.text = line.text.slice(0, sourceCol) + newTexts[0]
      newSourceLines = newTexts.slice(1).map(text => ({text: prefix + text}))
      const lastLine = newSourceLines[newSourceLines.length - 1]
      resultSourceLine = sourceLine + newTexts.length - 1
      resultSourceCol = lastLine.text.length
      lastLine.text += afterText
      this.sourceLines.splice(sourceLine + 1, 0, ...newSourceLines)
    }

    this.markEditStatus()
    this.updateUiForLine(line, newSourceLines.map(line => this.wrapLine(line)).flat())

    return [resultSourceLine, resultSourceCol]
  }

  eraseCharacter(endPosition) {
    return this.eraseRange(this.getPreviousCharacter(endPosition), endPosition)
  }

  eraseCharacterForward(startPosition) {
    return this.eraseRange(startPosition, this.getNextCharacter(startPosition))
  }

  getPreviousCharacter(position) {
    if (this.isSamePosition(position, this.getStartOfSource())) {
      return position
    }

    let [ sourceLine, sourceCol ] = position

    if (sourceCol === 0) {
      sourceLine--
      sourceCol = this.sourceLines[sourceLine].text.length
    } else {
      sourceCol--
    }

    return [sourceLine, sourceCol]
  }

  getNextCharacter(position) {
    if (this.isSamePosition(position, this.getEndOfSource())) {
      return position
    }

    let [ sourceLine, sourceCol ] = position

    if (sourceCol === this.sourceLines[sourceLine].text.length) {
      sourceLine++
      sourceCol = 0
    } else {
      sourceCol++
    }

    return [sourceLine, sourceCol]
  }

  eraseWord(endPosition) {
    return this.eraseRange(this.getStartOfWord(this.eraseWordBoundary, endPosition), endPosition)
  }

  eraseWordForward(startPosition) {
    return this.eraseRange(startPosition, this.getEndOfWord(this.eraseWordBoundary, startPosition))
  }

  getStartOfWord(wordBoundary, position) {
    if (this.isSamePosition(position, this.getStartOfSource())) {
      return position
    }

    let [ sourceLine, sourceCol ] = position

    while (wordBoundary.includes(this.sourceLines[sourceLine].text[sourceCol - 1])) {
      sourceCol--
    }

    while (sourceCol === 0 && sourceLine > 0) {
      sourceLine--
      sourceCol = this.sourceLines[sourceLine].text.trimEnd().length
    }

    const { text } = this.sourceLines[sourceLine]
    while (!wordBoundary.includes(text[sourceCol - 1]) && sourceCol > 0) {
      sourceCol--
    }

    return [sourceLine, sourceCol]
  }

  getEndOfWord(wordBoundary, position) {
    if (this.isSamePosition(position, this.getEndOfSource())) {
      return position
    }

    let [ sourceLine, sourceCol ] = position

    while (wordBoundary.includes(this.sourceLines[sourceLine].text[sourceCol])) {
      sourceCol++
    }

    let { text } = this.sourceLines[sourceLine]
    while (sourceCol === text.length && sourceLine < this.sourceLines.length - 1) {
      sourceLine++
      sourceCol = text.length - text.trimStart().length
      text = this.sourceLines[sourceLine].text
    }

    while (!wordBoundary.includes(text[sourceCol]) && sourceCol < text.length) {
      sourceCol++
    }

    return [sourceLine, sourceCol]
  }

  getStartOfLine([sourceLine,]) {
    return [sourceLine, 0]
  }

  getEndOfLine([sourceLine,]) {
    const { text } = this.sourceLines[sourceLine]
    return [sourceLine, text.length]
  }

  getStartOfSource() {
    return [0, 0]
  }

  getEndOfSource() {
    if (this.displayedSourceLines.length) {
      return [this.displayedSourceLines.length - 1, this.sourceLines[this.displayedSourceLines.length - 1].text.length]
    } else {
      return [0, 0]
    }
  }

  isSamePosition([lineA, colA], [lineB, colB]) {
    return lineA === lineB && colA === colB
  }

  eraseRange([startLine, startCol], [endLine, endCol]) {
    // If the deletion range spreads across more than two lines, it contains
    // in entirety all lines except the first and start. Remove those lines.
    if (endLine - startLine > 1) {
      for (let i = startLine + 1; i < endLine; i++) {
        this.replaceUiLines(this.sourceLines[i], [])
      }
      const numRemoved = endLine - (startLine + 1)
      this.sourceLines.splice(startLine + 1, numRemoved)
      // Offset endLine as well.
      endLine -= numRemoved
    }

    // If the selection spreads across exactly two lines (which can be the
    // case naturally, or if the selection originally spread across more than
    // two lines), the line break between the two will be removed. To effect
    // this, remove the second line's object, and append its text (past the
    // end column) to the first line (prior to the start column).
    if (endLine - startLine === 1) {
      const firstText = this.sourceLines[startLine].text.slice(0, startCol)
      const secondText = this.sourceLines[endLine].text.slice(endCol)
      this.replaceUiLines(this.sourceLines[endLine], [])
      this.sourceLines.splice(endLine, 1)
      this.sourceLines[startLine].text = firstText + secondText
      this.updateUiForLine(this.sourceLines[startLine])
    }

    // If the selection is contained within a single line, just modify the
    // text for that line accordingly.
    if (endLine - startLine === 0) {
      const line = this.sourceLines[startLine]
      const firstText = line.text.slice(0, startCol)
      const secondText = line.text.slice(endCol)
      line.text = firstText + secondText
      this.updateUiForLine(line)
    }

    this.markEditStatus()
    this.scheduleDrawWithoutPropertyChange()

    return [startLine, startCol]
  }

  replaceUiLines(line, newUiLines) {
    const firstIndex = this.uiLines.findIndex(uiLine => uiLine.sourceLine === line)
    let lastIndex = this.uiLines.slice(firstIndex).findIndex(uiLine => uiLine.sourceLine !== line)
    if (lastIndex === -1) {
      lastIndex = this.uiLines.length
    } else {
      lastIndex += firstIndex
    }
    this.uiLines.splice(firstIndex, lastIndex - firstIndex, ...newUiLines)
  }

  updateUiForLine(line, additionalLines = []) {
    const uiLines = this.wrapLine(line)
    this.replaceUiLines(line, uiLines.concat(additionalLines))
  }

  wrapLine(sourceLine) {
    const { text } = sourceLine
    const lines = []
    for (let i = 0; i <= text.length; i += this.maxLineWidth) {
      lines.push({
        sourceLine,
        startI: i,
        endI: Math.min(i + this.maxLineWidth, text.length)
      })
    }
    return lines
  }

  drawTo(writable) {
    for (let i = this.scrollLine; i < Math.min(this.uiLines.length, this.scrollLine + this.contentH); i++) {
      const { sourceLine, startI, endI } = this.uiLines[i]
      const text = sourceLine.text.slice(startI, endI)
      writable.write(ansi.moveCursor(this.absTop + i - this.scrollLine, this.absLeft))
      writable.write(text)
    }

    if (this.statusLineVisible) {
      writable.write(ansi.setBackground(ansi.C_BLACK))
      writable.write(ansi.moveCursor(this.absTop + this.h - 1, this.absLeft))
      writable.write(' '.repeat(this.w))
      writable.write(ansi.moveCursor(this.absTop + this.h - 1, this.absLeft))
      this.drawStatusLineContentsTo(writable,
        col => ansi.moveCursor(this.absTop + this.h - 1, this.absLeft + col))
      writable.write(ansi.resetAttributes())
    }
  }

  drawStatusLineContentsTo(writable, setColumn) {
    writable.write(setColumn(1))
    const maxColumn = this.w - 1

    let leftEndColumn = 1
    const latestStatusMessage = this.statusMessages[this.statusMessages.length - 1]
    if (latestStatusMessage) {
      const { text, date } = latestStatusMessage
      if (date > Date.now() - this.statusMessageScreenTime) {
        writable.write(text.slice(0, maxColumn - leftEndColumn))
        leftEndColumn += Math.min(maxColumn - leftEndColumn, text.length)
      }
    }

    const cursorStatus = `${this.cursorSourceLine + 1}/${this.displayedSourceLines.length}:${this.cursorSourceCol}`
    const idealColumn = maxColumn - cursorStatus.length
    const usedColumn = Math.max(leftEndColumn + 1, idealColumn)
    if (usedColumn < maxColumn) {
      writable.write(setColumn(usedColumn))
      writable.write(cursorStatus.slice(usedColumn - idealColumn))
    }
  }

  showStatusMessage(text) {
    const date = Date.now()
    this.statusMessages.push({text, date})

    // Draw now, and again when the time has passed and this message should be
    // hidden.
    this.scheduleDrawWithoutPropertyChange()
    setTimeout(() => this.scheduleDrawWithoutPropertyChange(), this.statusMessageScreenTime)
  }

  markEditStatus() {
    this.hasBeenEdited = true
  }

  clearEditStatus() {
    this.hasBeenEdited = false
  }

  get displayedSourceLines() {
    const last = this.sourceLines[this.sourceLines.length - 1]
    if (this.sourceLines.length > 1 && last.text === '') {
      return this.sourceLines.slice(0, -1)
    } else {
      return this.sourceLines.slice()
    }
  }

  get contentW() {
    return this.w - (this.scrollBar.visible ? 1 : 0)
  }

  get contentH() {
    return this.h - (this.statusLineVisible ? 1 : 0)
  }

  getOptimalHeight() {
    // Basically the inverse to contentH: returns the height with which the
    // editor would display all UI lines at once. Probably wise to call
    // rebuildUiLines() before this!
    return this.uiLines.length + (this.statusLineVisible ? 1 : 0)
  }

  get cursorX() {
    return this.cursorUiCol
  }

  get cursorY() {
    return this.cursorUiLine - this.scrollLine
  }

  // The superclass sets cursorX/Y, so we need to define /some/ setter method
  // for each of these. It doesn't do anything though.
  set cursorX(v) {}
  set cursorY(v) {}

  get cursorUiLine() {
    const sourceLine = this.sourceLines[this.cursorSourceLine]
    return this.uiLines.findIndex(line =>
      line.sourceLine === sourceLine &&
      line.startI <= this.cursorSourceCol &&
      line.endI >= this.cursorSourceCol)
  }

  get cursorUiCol() {
    return this.cursorSourceCol - this.uiLines[this.cursorUiLine].startI
  }

  get cursorPosition() {
    return [this.cursorSourceLine, this.cursorSourceCol]
  }

  get maxLineWidth() {
    return Math.max(this.contentW, 1)
  }

  get cursorSourceCol() { return this.getDep('cursorSourceCol') }
  set cursorSourceCol(v) { return this.setDep('cursorSourceCol', v) }
  get cursorSourceLine() { return this.getDep('cursorSourceLine') }
  set cursorSourceLine(v) { return this.setDep('cursorSourceLine', v) }
  get scrollLine() { return this.getDep('scrollLine') }
  set scrollLine(v) { return this.setDep('scrollLine', v) }
}

module.exports = TuiTextEditor
